// Copyright (C) 2013 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.server.change;

import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Objects.requireNonNull;

import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetInfo;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ValidationOptionsListener;
import com.google.gerrit.server.approval.ApprovalCopier;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.extensions.events.RevisionCreated;
import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
import com.google.gerrit.server.git.validators.CommitValidationException;
import com.google.gerrit.server.git.validators.CommitValidationInfo;
import com.google.gerrit.server.git.validators.CommitValidationInfoListener;
import com.google.gerrit.server.git.validators.CommitValidators;
import com.google.gerrit.server.git.validators.TopicValidator;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.patch.AutoMerger;
import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
import com.google.gerrit.server.patch.PatchSetInfoFactory;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.plugincontext.PluginSetContext;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.ssh.NoSshInfo;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.PostUpdateContext;
import com.google.gerrit.server.update.RepoContext;
import com.google.gerrit.server.validators.ValidationException;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.transport.ReceiveCommand;

public class PatchSetInserter implements BatchUpdateOp {
  public interface Factory {
    PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
  }

  // Injected fields.
  private final PermissionBackend permissionBackend;
  private final PatchSetInfoFactory patchSetInfoFactory;
  private final ChangeKindCache changeKindCache;
  private final CommitValidators.Factory commitValidatorsFactory;
  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
  private final ProjectCache projectCache;
  private final RevisionCreated revisionCreated;
  private final ApprovalsUtil approvalsUtil;
  private final ChangeMessagesUtil cmUtil;
  private final PatchSetUtil psUtil;
  private final WorkInProgressStateChanged wipStateChanged;
  private final AutoMerger autoMerger;
  private final TopicValidator topicValidator;
  private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory;
  private final PluginSetContext<ValidationOptionsListener> validationOptionsListeners;
  private final PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners;

  // Assisted-injected fields.
  private final PatchSet.Id psId;
  private final ObjectId commitId;
  // Read prior to running the batch update, so must only be used during
  // updateRepo; updateChange and later must use the notes from the
  // ChangeContext.
  private final ChangeNotes origNotes;

  // Fields exposed as setters.
  private String message;
  private String description;
  private Boolean workInProgress;
  private boolean validate = true;
  private ImmutableMap<String, CommitValidationInfo> validationInfos;
  private boolean checkAddPatchSetPermission = true;
  private List<String> groups = Collections.emptyList();
  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
  private boolean fireRevisionCreated = true;
  private boolean allowClosed;
  private boolean sendEmail = true;
  private String topic;
  private PatchSet.Conflicts conflicts;
  private boolean storeCopiedVotes = true;

  // Fields set during some phase of BatchUpdate.Op.
  private Change change;
  private PatchSet patchSet;
  private PatchSetInfo patchSetInfo;
  private ChangeKind changeKind;
  private String mailMessage;
  private ReviewerSet oldReviewers;
  private boolean oldWorkInProgressState;
  private ApprovalCopier.Result approvalCopierResult;
  private ObjectId preUpdateMetaId;

  @Inject
  public PatchSetInserter(
      PermissionBackend permissionBackend,
      ApprovalsUtil approvalsUtil,
      ChangeMessagesUtil cmUtil,
      PatchSetInfoFactory patchSetInfoFactory,
      ChangeKindCache changeKindCache,
      CommitValidators.Factory commitValidatorsFactory,
      EmailNewPatchSet.Factory emailNewPatchSetFactory,
      PatchSetUtil psUtil,
      RevisionCreated revisionCreated,
      ProjectCache projectCache,
      WorkInProgressStateChanged wipStateChanged,
      AutoMerger autoMerger,
      TopicValidator topicValidator,
      DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory,
      PluginSetContext<ValidationOptionsListener> validationOptionsListeners,
      PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners,
      @Assisted ChangeNotes notes,
      @Assisted PatchSet.Id psId,
      @Assisted ObjectId commitId) {
    this.permissionBackend = permissionBackend;
    this.approvalsUtil = approvalsUtil;
    this.cmUtil = cmUtil;
    this.patchSetInfoFactory = patchSetInfoFactory;
    this.changeKindCache = changeKindCache;
    this.commitValidatorsFactory = commitValidatorsFactory;
    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
    this.psUtil = psUtil;
    this.revisionCreated = revisionCreated;
    this.projectCache = projectCache;
    this.wipStateChanged = wipStateChanged;
    this.autoMerger = autoMerger;
    this.topicValidator = topicValidator;
    this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
    this.validationOptionsListeners = validationOptionsListeners;
    this.commitValidationInfoListeners = commitValidationInfoListeners;

    this.origNotes = notes;
    this.psId = psId;
    this.commitId = commitId.copy();
  }

  public PatchSet.Id getPatchSetId() {
    return psId;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setMessage(String message) {
    this.message = message;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setDescription(String description) {
    this.description = description;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setWorkInProgress(boolean workInProgress) {
    this.workInProgress = workInProgress;
    return this;
  }

  /**
   * Disables the commit validation because validation is not needed.
   *
   * @return the {@link PatchSetInserter} instance to allow chaining calls
   */
  @CanIgnoreReturnValue
  public PatchSetInserter disableValidation() {
    return disableValidation(null);
  }

  /**
   * Disables the commit validation because the validation has already been done.
   *
   * <p>The result from the validation that has already been done needs to be provided to this
   * method and is being used to invoke the {@link CommitValidationInfoListener}'s.
   *
   * @param validationInfos result of validating {@link #commitId}
   * @return the {@link PatchSetInserter} instance to allow chaining calls
   */
  @CanIgnoreReturnValue
  public PatchSetInserter disableValidation(
      @Nullable ImmutableMap<String, CommitValidationInfo> validationInfos) {
    this.validate = false;
    this.validationInfos = validationInfos;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setCheckAddPatchSetPermission(boolean checkAddPatchSetPermission) {
    this.checkAddPatchSetPermission = checkAddPatchSetPermission;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setGroups(List<String> groups) {
    requireNonNull(groups, "groups may not be null");
    this.groups = groups;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setValidationOptions(
      ImmutableListMultimap<String, String> validationOptions) {
    requireNonNull(validationOptions, "validationOptions may not be null");
    this.validationOptions = validationOptions;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setFireRevisionCreated(boolean fireRevisionCreated) {
    this.fireRevisionCreated = fireRevisionCreated;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setAllowClosed(boolean allowClosed) {
    this.allowClosed = allowClosed;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setSendEmail(boolean sendEmail) {
    this.sendEmail = sendEmail;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setTopic(String topic) {
    this.topic = topic;
    return this;
  }

  @CanIgnoreReturnValue
  public PatchSetInserter setConflicts(PatchSet.Conflicts conflicts) {
    this.conflicts = conflicts;
    return this;
  }

  /**
   * We always want to store copied votes except when the change is getting submitted and a new
   * patch-set is created on submit (using submit strategies such as "REBASE_ALWAYS"). In such
   * cases, we already store the votes of the new patch-sets in SubmitStrategyOp#saveApprovals. We
   * should not also store the copied votes.
   */
  @CanIgnoreReturnValue
  public PatchSetInserter setStoreCopiedVotes(boolean storeCopiedVotes) {
    this.storeCopiedVotes = storeCopiedVotes;
    return this;
  }

  public Change getChange() {
    checkState(change != null, "getChange() only valid after executing update");
    return change;
  }

  public PatchSet getPatchSet() {
    checkState(patchSet != null, "getPatchSet() only valid after executing update");
    return patchSet;
  }

  @Override
  public void updateRepo(RepoContext ctx)
      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
    validate(ctx);
    ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());

    changeKind =
        changeKindCache.getChangeKind(
            ctx.getProject(),
            ctx.getRevWalk(),
            ctx.getRepoView().getConfig(),
            ctx.getRepoView().getAttributesNodeProvider(),
            psUtil.current(origNotes).commitId(),
            commitId);

    Optional<ReceiveCommand> autoMerge =
        autoMerger.createAutoMergeCommitIfNecessary(
            ctx.getRepoView(), ctx.getInserter(), ctx.getRevWalk().parseCommit(commitId));
    if (autoMerge.isPresent()) {
      ctx.addRefUpdate(autoMerge.get());
    }
  }

  @Override
  public boolean updateChange(ChangeContext ctx)
      throws ResourceConflictException, IOException, BadRequestException {
    preUpdateMetaId = ctx.getNotes().getMetaId();
    change = ctx.getChange();
    ChangeUpdate update = ctx.getUpdate(psId);
    update.setSubjectForCommit("Create patch set " + psId.get());

    if (!change.isNew() && !allowClosed) {
      throw new ResourceConflictException(
          String.format(
              "Cannot create new patch set of change %s because it is %s",
              change.getId(), ChangeUtil.status(change)));
    }

    List<String> newGroups = groups;
    if (newGroups.isEmpty()) {
      PatchSet prevPs = psUtil.current(ctx.getNotes());
      if (prevPs != null) {
        newGroups = prevPs.groups();
      }
    }
    patchSet =
        psUtil.insert(
            ctx.getRevWalk(), ctx.getUpdate(psId), psId, commitId, newGroups, null, description);

    if (!ctx.getNotify(change.getId()).handling().equals(NotifyHandling.NONE)) {
      oldReviewers = approvalsUtil.getReviewers(ctx.getNotes());
    }

    oldWorkInProgressState = change.isWorkInProgress();
    if (workInProgress != null) {
      change.setWorkInProgress(workInProgress);
      change.setReviewStarted(!workInProgress);
      update.setWorkInProgress(workInProgress);
    }

    patchSetInfo =
        patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
    if (!allowClosed) {
      change.setStatus(Change.Status.NEW);
    }
    change.setCurrentPatchSet(patchSetInfo);
    if (topic != null) {
      change.setTopic(topic);
      try {
        update.setTopic(topic, topicValidator);
      } catch (ValidationException ex) {
        throw new BadRequestException(ex.getMessage());
      }
    }

    if (storeCopiedVotes) {
      approvalCopierResult =
          approvalsUtil.copyApprovalsToNewPatchSet(
              ctx.getNotes(), patchSet, ctx.getRepoView(), update);
    }

    if (conflicts != null) {
      update.setConflicts(conflicts);
    }

    mailMessage = insertChangeMessage(update);

    return true;
  }

  @Nullable
  private String insertChangeMessage(ChangeUpdate update) {
    StringBuilder messageBuilder = new StringBuilder();
    if (message != null) {
      messageBuilder.append(message);
    }

    if (approvalCopierResult != null) {
      approvalsUtil
          .formatApprovalCopierResult(approvalCopierResult)
          .ifPresent(
              msg -> {
                if (message != null && !message.endsWith("\n")) {
                  messageBuilder.append("\n");
                }
                messageBuilder.append("\n").append(msg);
              });
    }

    String changeMessage = messageBuilder.toString();
    if (changeMessage.isEmpty()) {
      return null;
    }

    return cmUtil.setChangeMessage(
        update,
        messageBuilder.toString(),
        ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
  }

  @Override
  public void postUpdate(PostUpdateContext ctx) {
    NotifyResolver.Result notify = ctx.getNotify(change.getId());
    if (sendEmail) {
      emailNewPatchSetFactory
          .create(
              ctx,
              patchSet,
              mailMessage,
              approvalCopierResult.outdatedApprovals().stream()
                  .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
                  .collect(toImmutableSet()),
              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(REVIEWER),
              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(CC),
              changeKind,
              preUpdateMetaId)
          .sendAsync();
    }

    if (fireRevisionCreated) {
      revisionCreated.fire(
          ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen(), notify);
    }

    if (workInProgress != null && oldWorkInProgressState != workInProgress) {
      wipStateChanged.fire(ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen());
    }
  }

  private void validate(RepoContext ctx)
      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
    // Not allowed to create a new patch set if the current patch set is locked.
    psUtil.checkPatchSetNotLocked(origNotes);

    if (checkAddPatchSetPermission) {
      permissionBackend.user(ctx.getUser()).change(origNotes).check(ChangePermission.ADD_PATCH_SET);
    }
    projectCache
        .get(ctx.getProject())
        .orElseThrow(illegalState(ctx.getProject()))
        .checkStatePermitsWrite();

    String refName = getPatchSetId().toRefName();
    try (CommitReceivedEvent event =
        new CommitReceivedEvent(
            new ReceiveCommand(
                ObjectId.zeroId(),
                commitId,
                refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
            projectCache
                .get(origNotes.getProjectName())
                .orElseThrow(illegalState(origNotes.getProjectName()))
                .getProject(),
            origNotes.getChange().getDest().branch(),
            validationOptions,
            ctx.getRepoView().getConfig(),
            ctx.getRevWalk().getObjectReader(),
            commitId,
            ctx.getIdentifiedUser(),
            diffOperationsForCommitValidationFactory.create(
                ctx.getRepoView(), ctx.getInserter()))) {
      if (!validate) {
        if (validationInfos != null) {
          commitValidationInfoListeners.runEach(
              commitValidationInfoListener ->
                  commitValidationInfoListener.commitValidated(validationInfos, event, psId));
        }
        return;
      }

      validationOptionsListeners.runEach(
          validationOptionsListener ->
              validationOptionsListener.onPatchSetCreation(
                  origNotes.getChange().getDest(), psId, validationOptions));
      try {
        commitValidatorsFactory
            .forGerritCommits(
                permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
                origNotes.getChange().getDest(),
                ctx.getIdentifiedUser(),
                new NoSshInfo(),
                ctx.getRevWalk(),
                origNotes.getChange())
            .patchSet(psId)
            .validate(event);
      } catch (CommitValidationException e) {
        throw new ResourceConflictException(e.getFullMessage());
      }
    }
  }
}
